fix(js): preserve real exit code when test reset/parent-stream closure races in-flight commands (#170)#171
Conversation
Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: #170
…ands (#170) The CI test 'should provide better error objects than bash' intermittently reported exit code 143 / stderr 'Process killed with SIGTERM' instead of the real exit code (Windows/Bun, run 27310950658). Two defects in the global test-isolation reset were responsible: 1. cleanupActiveRunners() force-killed commands that user code was still awaiting, replacing the real exit code with a synthetic SIGTERM result. The reaper now skips awaited, not-yet-finished runners. A new _awaited flag is set synchronously when user code starts consuming a runner (await/then/catch/finally/stream). 2. monitorParentStreams() leaked a 'close' listener on process.stdout/stderr on every ProcessRunner construction (resetGlobalState cleared the flag but not the listeners), eventually triggering a MaxListenersExceeded warning. Listeners are now tracked and removed on reset. Adds js/tests/issue-170-cleanup-race.test.mjs reproducing both defects (3 fail on clean code, 3 pass with the fix) and experiments/repro-issue-170-awaited.mjs.
…ison Captures the failing run 27310950658 log, the JS pipeline-template workflows used for comparison, and a full analysis: timeline, requirements, root-cause of both defects (reaper race + parent-stream listener leak), the fix, the workflow comparison (no matching defect in templates), and verification.
The real CI trigger (run 27310950658, Windows/Bun) was a spurious 'close' event on the parent stdout WriteStream while an errexit command was in flight and being awaited. monitorParentStreams' listener fired _handleParentStreamClosure(), which killed the live child and replaced the real exit code (5) with a synthetic SIGTERM (143), failing the 'should provide better error objects than bash' test. The earlier reaper/listener-leak fixes were necessary but insufficient: the kill came from the parent-closure handler, not cleanupActiveRunners. Guard _handleParentStreamClosure() on this._awaited so graceful shutdown on parent-stream closure only applies to fire-and-forget/streamed runners whose output consumer went away — never to a command the user is awaiting, where the await is the authoritative consumer. Adds a regression test (verified: 143 without the guard, 5 with it) and a standalone repro under experiments/.
Update the case study and changeset to reflect that the reaper-only fix (9fd7ad2) was insufficient: CI still failed because the parent-stream closure handler (_handleParentStreamClosure) was the path that preempted the awaited command on Windows/Bun. Documents both teardown paths (reaper + parent-closure), the MaxListeners warning as the smoking gun for the spurious WriteStream close, and the unified _awaited-based fix.
The Rust implementation's reset() only clears the active-runner set and has no parent-stream-closure/synthetic-SIGTERM machinery, so none of the three JS defects has a Rust counterpart. The PR is labeled parity-exempt per the gate's documented escape hatch for single-language changes.
Working session summaryAll checks are green or appropriately skipped. The work for issue #170 is complete. SummaryThe CI failure in issue #170 (run 27310950658,
Verification:
Other requirements:
PR: #171 This summary was automatically extracted from the AI working session output. |
🤖 Solution Draft LogThis log file contains the complete execution trace of the AI solution draft process. 💰 Cost: $16.379092📊 Context and tokens usage:Claude Opus 4.8: (4 sub-sessions)
Total: (32.8K new + 455.9K cache writes + 17.6M cache reads) input tokens, 182.5K output tokens, $16.379092 cost 🤖 Models used:
📎 Log file uploaded as Gist (6872KB)Now working session is ended, feel free to review and add any feedback on the solution draft. |
🎉 Auto-mergedThis pull request has been automatically merged by hive-mind.
Auto-merged by hive-mind with --auto-merge flag |
Summary
Fixes #170 — a CI/CD false positive on
Test JavaScript (bun on windows-latest)(run 27310950658).
The test
Shell Settings > Shell Replacement Benefits > should provide better error objects than bashintermittently reported exit code 143 with stderr"Process killed with SIGTERM"instead of the command's real exit code 5.The same run also emitted
MaxListenersExceededWarning: … 11 close listeners.These are flaky/quality defects in the library's own source — not in any workflow
file. Full analysis (timeline, requirements, root causes, verification, workflow
comparison) lives in
docs/case-studies/issue-170/.Root causes
The unifying cause is a global teardown preempting a command that user code is
actively awaiting, replacing its real exit code with a SIGTERM (143). Two
distinct teardown paths reach an in-flight, awaited runner during a
resetGlobalState():monitorParentStreams()'s'close'listener calls_handleParentStreamClosure()on every active runner, which aborts and killsits child. On Windows/Bun the parent
WriteStreamemits a spurious'close'(the same instability behind the MaxListeners warning), preemptingthe awaited command. A reaper-only fix (commit
9fd7ad2) left this path openand CI still failed — see the case study §3.3.
cleanupActiveRunners()(run inbeforeEach/afterEach)force-killed a still-awaited command; the synthetic SIGTERM from
killRunner()won (
finish()is first-wins) and masked the real code.monitorParentStreams()added a'close'listener on everyProcessRunnerconstruction, butresetGlobalState()cleared theparentStreamsMonitoredflag without removing the listeners — so theyaccumulated until the MaxListeners warning. This warning is the smoking gun for
the spurious-close instability behind root cause $fy tool (sh to mjs translator) #1.
The fix (applied across the whole codebase)
A new
_awaitedflag is set synchronously the moment user code consumes arunner (
then/catch/finally/stream). Both teardown paths honor it:_handleParentStreamClosure()ignores parent-stream closure for awaitedrunners — the
awaitis the authoritative consumer. Fire-and-forget/streamedrunners (
runner.start()without awaiting the runner) are unaffected, so thelegitimate graceful-shutdown path is preserved.
cleanupActiveRunners()skips live, awaited runners while still reapinggenuinely leaked fire-and-forget ones.
'close'listeners are now tracked and removed inresetGlobalState()/resetParentStreamMonitoring(), bounding the count.How to reproduce
Tests
js/tests/issue-170-cleanup-race.test.mjsencodes allthree defects. The parent-closure case was verified to go 143 without the
guard → 5 with it.
Test JavaScript (bun on windows-latest)— the originally failing leg — nowpasses on this branch (CI run on commit
1c53eef).JS/Rust parity
This change is JS-only. The Rust implementation's
reset()(rust/src/state.rs)merely clears the active-runner set; it has no parent-stream-closure monitoring,
no reaper kill, and no synthetic-SIGTERM result — so none of the three defects has
a Rust counterpart. The PR carries the
parity-exemptlabel per the parity gate'sdocumented escape hatch.
Workflow comparison (issue requirement R2)
Compared
.github/workflows/*.ymlagainst the js/rust/python/csharp pipelinetemplates. The failing defect lives in command-stream's library source, which has
no counterpart in the templates, so there is no matching issue to file
upstream. Best-practice deltas (Deno matrix leg, fresh-merge simulation) are
documented as follow-up recommendations in the case study.
A changeset (
js/.changeset/issue-170-cleanup-race.md,patch) is included totrigger the next release.